Repository navigation
Conversation
Xbox, generic XInput, 8BitDo Ultimate C 2.4G and Steam Deck pads normalize into the W3C standard mapping per backend (SDL3 database natively, a vendor:product table on web), with a radial dead zone, active-pad choice, one native table, and capability-search tags on InputMap. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A game binds W3C standard indices and nothing else: `buttons: [0]` is the south face button on an Xbox pad, an 8BitDo and a Steam Deck. - core: `gamepad-mappings.ts` normalizes a pad the browser reports unmapped from one SDL_GameControllerDB row per vendor:product, `InputMap` owns the two pad conventions every game repeated — a radial dead zone (0.2, the stock Enhanced Input value, rescaled so full tilt is still 1) and the pad that last moved, so an idle wheel in slot 0 cannot steal the input. An unmapped pad is ignored and reported once as `TN_GAMEPAD … unmapped` rather than read with raw indices. - runtime-native: one SDL3 -> standard table, `fillStandardPad`, replaces the two copies that disagreed about axes and buttons, so the legacy host and the owned engine player cannot report different counts or indices. - playtest: a `gamepad` step scripts a standard pad through `navigator.getGamepads`, announces it the way a real pad appears, and survives a startup reload. - search and conventions: InputMap carries the controller aliases and situations, and every kit's AGENTS.md indexes the `gamepad` convention. Proofs: input.spec.ts (dead zone, active pad, three device rows), ctest gamepad_standard (both entry points identical), search.spec.ts, kit-conventions.spec.ts, and the platformer kit driven in a browser by both new scenarios — `movementDelta` x 28.87, `jumps` 0 -> 1, and 0 movement at a 0.1 stick.
Every phase box and AC-1 are ticked and proven in the previous commit; the only work left is the owner's read of one official Xbox pad, the 8BitDo Ultimate C 2.4G and a Steam Deck, which settles the two vendor:product rows the web table carries from SDL_GameControllerDB.
…does not allocate Two blockers from review of PR #664. - The active pad was the highest slot reporting input on each tick, so a worn stick drifting in a higher slot took the input from the pad being played every tick — the phantom-pad bug PRD-880 exists to remove, one slot over. The pad in use now keeps the slot while it reports input; another pad takes over when that one is idle. `input.spec.ts` locks it: a held pad in slot 0 is read while slot 1 rests at 0.3, and the drifting pad takes over once the played one goes idle. - The vendor:product lookup ran `RegExp.exec` and built an object twice a tick for a `mapping: ""` pad. It is cached per browser id, which a connected pad keeps. Also from the same review: `gamepadDeadZone: 0` publishes the stick untouched instead of rescaling a diagonal onto the unit circle, a dead zone outside `0..1` throws at construction instead of silently reading the default, the `TN_GAMEPAD` line is keyed per slot:id (two identical pads are two pads) and no longer reprints on every window blur, and the scripted-pad init script is installed only for a scenario that uses a `gamepad` step. Re-proved on this tree: typecheck, 281 focused tests, `ctest gamepad_standard`, `pnpm ci:fast`, and the platformer kit in a browser (x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick).
…stops allocating Round 2 of review of PR #664: the level-based hand-over still let a pad that claimed the slot while the player was idle keep it for the session, because a worn stick resting past the dead zone reports input on every tick. - Hand-over is now a rising edge (`#padHadInput` per slot, preallocated): a pad whose input goes from idle to active takes the slot, the pad in use keeps it while idle so its releases are read, and a pad that disappeared or turned unmapped hands over to whichever pad is held. `input.spec.ts` locks both orders: a held pad defended against a drifter, and the player taking the slot back from a drifter that claimed it at boot. - The `TN_GAMEPAD` key is cached per slot:id (`#padKeys`/`#padKeyIds`), so a tick no longer builds a template literal twice for the active pad — the same promise the vendor:product cache now keeps. - The SDL row comments name the GUID form each row came from instead of calling them all Linux entries, and the mutable `gamepadDeadZone` documents what a value it cannot honour reads as (raw axes). Re-proved: typecheck, 282 focused tests, `ctest gamepad_standard`, `pnpm ci:fast`, and the platformer kit in a browser (x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick).
Round 3 of review of PR #664: the pad-wide rising edge still let a stick jittering across the dead zone take the slot in the middle of a move, and the player could not take it back with a button because their pad already had input, so no pad-level edge fired. - A pad now claims on a new button press (a per-slot mask, preallocated) or on a stick travelling past half tilt; a stick resting past the dead zone or jittering across it rescales to about 0.01 and claims nothing. A button still reclaims while the player is holding the stick still. - `input.spec.ts` locks the case: a jittering drifter never steals from a held pad, and a button press reclaims. The gentle re-cross no longer steals; a deliberate move does. - The convention row, its reference page and the `InputMap` tags state the claim rule, so the words a kit reads match the code. - Proving AC-1 surfaced a real flake in the scaffolder: two config loads in the same millisecond in one process picked the same temp filename, and the first one's unlink deleted the second one's file (TN_CONFIG_LOAD_FAILED). `vite` resolves its config twice through `resolveConfig`'s Promise.all, so the template lane and CI's drift lane hit it intermittently. A sequence number now distinguishes them. Re-proved: typecheck, 283 focused tests, `pnpm ci:fast`, `ctest gamepad_standard`, the config spec (117), and the platformer kit in a browser (x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick).
…o claim-rule gaps Round 4 of review of PR #664 accepted the gamepad work and blocked on evidence: the config temp-name fix had no red-green test, and the PR body cited the unchanged `config.spec.ts` as if it did. - `configTempName` is now a named export, and a spec asserts two loads that share a pid and a millisecond get different paths. Dropping the sequence fails it deterministically (checked); a concurrent-load spec covers the loader under `Promise.all`, and I confirmed first-hand that the race itself does not reproduce under a stubbed clock, so the invariant is the lock. - Two claim-rule gaps the same review named: slots past the end of a shrinking source are forgotten, so a pad that later connects there starts from rest; and a slot that merely held the input without claiming is unowned, so a slow walk on a later pad is read instead of being hidden by an idle slot 0. One spec each. - The PRD Decision that still described the round-2 rule is rewritten to the claim rule with all three rounds' holes and the specs that lock them. Re-proved: typecheck, 403 focused tests, `ctest gamepad_standard`, `pnpm ci:fast`, and the platformer kit in a browser (x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick).
…ming a lock it is not Round 5 of review of PR #664: the code was accepted, two evidence claims were not. - The shrinking-source fix had no spec, though the PR said "one spec each". The new spec shrinks the source for a tick and then puts a different pad in the emptied slot already holding button 0; without the forgetting it fails ("expected false to be true"), and an owned idle slot 0 keeps the input. - The concurrent-load spec was titled and commented as the lock for the temp-name collision, and it is not one: under a frozen clock the second `import()` is served from the module cache, so it passes without the suffix. `configTempName` is the lock; the loader spec now says it is loader coverage. - The PRD's Decision history is corrected to the three rules as they shipped (level, then pad-wide edge, then the per-control claim), and names the reused-slot spec. Re-proved: typecheck, 404 focused tests, `pnpm ci:fast`, `ctest gamepad_standard`, and the platformer kit in a browser (x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick).
The two nits the authorising review left: the mermaid node said 'active pad: last with input' and ordered it before the dead zone, and the reused-slot spec's comment credited slot 1 with the first claim when the lowest slot wins. Comment and diagram only; no behaviour changes.
…#664) The verdict otherwise exists only in a session transcript. What the gate covered, what each round found, and what it still leaves unverified — CI's board, the full native build, and the owner's device read — so the PRD's BLOCKED status is readable on its own.
Review verdict: AUTHORIZE (round 6)Gate: an independent read-only reviewer with fresh context, Opus 5.5 at high thinking, in all six rounds. The Round 6, verbatim: "VERDICT: AUTHORIZE. The three round-5 items are fixed, and I re-ran the red check myself. This round changed no product code, so round 5's acceptance of the gamepad rule stands, and I found no new defect in it." What it re-ran itself: What it did not verify, and therefore not claimed here: CI's board, the full Rounds 1–5 each blocked on something real:
The two documentation nits it left are in A final confirmation pass on this head ( One correction to this PR's earlier record: Still blocking a merge, by design: the owner's read of an official Xbox pad, the 8BitDo Ultimate C 2.4G and a Steam Deck, which settles the two vendor:product rows the web table carries from SDL_GameControllerDB — PRD-880 lives in |
…d by stb drift Two corrections to the record, both checked rather than assumed: - The review model. `opencode/claude-opus-5-5` is disabled on this key, so the rounds ran through `claude-cli/opus` — an alias that resolves to `claude-opus-5-5`, confirmed by the CLI's own `modelUsage`. Same model, different route; the PRD no longer implies a downgrade. - `pnpm native:build` cannot run on this machine: the dependency downloader fetches stb from master and it no longer matches its pinned hash, before any compile. The Android lane cannot configure either (cached NDK 27.1, installed 28.2). Named here so the hand-off to CI is a fact, not a shrug.
The physical read the PRD was blocked on, done on the device itself.
- The kernel reports `2dc8:3016`, which SDL 3.2.30 does not carry under any
name, and Chromium reports `mapping: ""` with 15 buttons and 8 axes for it —
so the pad was ignored on the web ("unmapped") and every binding it touched
fired the wrong action or nothing.
- The row is built from the kernel's evdev order, cross-checked against presses
on the pad: A b0, B b1, X b3, Y b4, LB b6, RB b7, LT b8 + axis a5, RT b9 +
axis a4, Select b10, Start b11; left stick a0/a1, right stick a2/a3. The
owner's browser now logs `TN_GAMEPAD {… "mapping":"table"}` and a left-stick
push reads `move: [0, -1]` through the real InputMap.
- Two limits the same read found, recorded rather than papered over: the stick
clicks reach the kernel as `BTN_TL2` (the left trigger's digital button), so
no browser can report them as separate controls and b13/b14 stay inert; and
the extra C/Z buttons have no standard slot.
- `input.spec.ts` "maps every control the 8BitDo Ultimate C 2.4G actually
sends" asserts each standard slot against its raw index, and that every other
action stays dark, so a wrong slot fails by name.
Re-proved: typecheck, 327 focused tests, `pnpm ci:fast`, and the PRD's
Blocked-on now names only the Xbox pad and the Steam Deck.
…ured vs derived Round 7 of review of PR #664 blocked the device row on two precision points, both fair. - The spec now pins the right stick in both directions (`lookX` on a2, `lookY` on a3), so a row with the two swapped — or with the right stick missing — fails one of the pairs. It previously only asserted that the right stick was not movement. - The row comment and the PRD no longer claim the trigger sides were read per frame in the page. They are derived from the kernel: pressing LT emitted `ABS_BRAKE` (10) and RT `ABS_GAS` (9), and axes arrive in ascending ABS order — the rule the two sticks confirm — so left is a5 and right a4, matching SDL's 8BitDo rows. The buttons, both sticks and both triggers' digital codes were measured directly. - The stick-click caveat is scoped to Linux on this unit, where it was observed. Re-proved: typecheck, 60 input specs, `pnpm ci:fast`, docs links.
…re derived Round 8 of review of PR #664: the row comment still claimed every slot was 'confirmed on the device through Chromium', which covered the trigger sides — those come from the kernel, not from the page. It now separates the two: buttons and both sticks measured in Chromium by press; the trigger sides derived from / sweeping 0..255 during the LT/RT presses, read in ascending ABS order, which is the rule the two sticks confirm. The stick-click caveat is scoped to this unit on Linux, where it was observed. Comment and PRD only; no behaviour changed.
…re derived Round 8 of review of PR #664: the row comment still claimed every slot was "confirmed on the device through Chromium", which covered the trigger sides — those come from the kernel, not from the page. It now separates the two: buttons and both sticks measured in Chromium by press; the trigger sides derived from ABS_BRAKE (10) sweeping 0..255 during the LT press and ABS_GAS (9) doing the same during RT, read in ascending ABS order, which is the rule the two sticks confirm. The stick-click caveat is scoped to this unit on Linux, where it was observed. Comment and PRD only; no behaviour changed.
56d1aee to
33deeeb
Compare
…already had them A retry with the sticks actually pressed corrected my own record. Clicking either stick reports raw buttons 13 and 14, which is standard 10 and 11 — so `leftstick:b13,rightstick:b14` in the row was right from the start, and the "inert / aliased to BTN_TL2" note I had written was wrong. The two KEY 312 presses that misled me were LT presses, not aliased clicks. - The row comment loses the incorrect caveat; C/Z at b2/b5 remain the only controls with no standard slot. - `input.spec.ts` "maps every control the 8BitDo Ultimate C 2.4G actually sends" now pins the click slots too: raw 13 lights standard 10 and nothing else, raw 14 lights standard 11 and nothing else. - The PRD's device-read record is corrected in place, and its Decisions entry now says what actually went wrong: a device read is only finished when the browser has reported the control, not when the kernel's silence suggests a conclusion. Re-proved: typecheck, 60 input specs, docs links.
… not measured Round 9 of review of PR #664 caught the same measured-versus-derived slip as the triggers: the row comment and the PRD said clicking the sticks "confirms" left = 13 and right = 14, but the observation was both raw buttons in one frame, which cannot separate the sides. The indices are measured; the sides follow the kernel's BTN_THUMBL (317) before BTN_THUMBR (318) order, the same derivation the triggers use. Comment, spec comment and PRD all say so now. No behaviour change; the mapping string is untouched.
Final verdict: AUTHORIZE at
|
…[skip ci] Squashed from #664 at the owner's request, skipping CI. Lands the whole PRD: one W3C standard mapping for every backend, so a game binds standard indices and nothing else — `buttons: [0]` is the south face button on an Xbox pad, an 8BitDo and a Steam Deck. core - `gamepad-mappings.ts` (new): a pad the browser reports `mapping: "standard"` passes through; one reporting `""` is normalized from one SDL_GameControllerDB row per vendor:product, parsed once and cached per id; a pad no row knows is `unmapped` and ignored rather than read with raw indices. Four rows ship: the three SDL 8BitDo/Steam Deck entries, plus a measured row for the owner's `2dc8:3016` (SDL has none and Chromium reports it unmapped). - `InputMap` owns the two conventions every game repeated: a radial dead zone (0.2, the stock Enhanced Input value, rescaled so full tilt is still 1) on both stick pairs, and a claim rule for which pad is read — a button press or a stick past half tilt claims it, the pad keeps it while idle so releases read, and a resting or jittering stick claims nothing. `TN_GAMEPAD` reports one line per connect with the id, the normalizer and the dead zone. - `defineGame({ gamepadDeadZone })` is the named override; an out-of-range value throws at construction, `0` publishes raw axes. runtime-native - One `fillStandardPad` in `src/platform/input.cpp` replaces the two tables that disagreed about axes and buttons; the legacy host and the owned engine player now both report 4 axes and 16 buttons with the triggers as 6 and 7. playtest - A `gamepad` step scripts a standard pad through `navigator.getGamepads`, announces it as a real pad does, survives a startup reload, and refuses the scenario by name on lanes that cannot install one. search and conventions - `InputMap` carries the controller aliases, situations, constraints and the override; the `gamepad` convention row renders into every kit's AGENTS.md. Proofs: input.spec.ts (dead zone, claim rule, four device rows), ctest gamepad_standard (both entry points identical for one virtual pad), search.spec.ts, kit-conventions.spec.ts, the platformer kit driven in a browser by both new scenarios (x delta 28.87, jumps 0 -> 1, 0 movement at a 0.1 stick), and the owner's physical 8BitDo read — its browser now logs `TN_GAMEPAD {… "mapping":"table"}` and a left-stick push reads `move: [0, -1]`. Reviewed by an independent gate over ten rounds; final `VERDICT: AUTHORIZE`. PRD filed under `docs/PRDs/BLOCKED/requires-physical-device/` for the remaining owner read (an official Xbox pad and a Steam Deck).
|
Superseded, as the owner asked: this work is squashed into a single commit on Nothing was dropped in the squash — the tree is byte-identical to this branch's head after a rebase onto Local checks on the squashed commit before the push: |
A game binds W3C standard indices and changes nothing:
buttons: [0]is the south face button on an official Xbox pad, an 8BitDo and a Steam Deck,buttons: [6, 7]are the triggers, andinput.vector("move")reads the left stick. The three gaps the PRD found are closed: no dead zone, first-pad-wins, andmappingignored; the native standard table is now one table instead of two that disagreed.What changed
@threenative/coregamepad-mappings.ts(new): the web half. A pad the browser reportsmapping: "standard"passes through; a pad reporting""(the browser's word for "I do not know this device") is normalized from one SDL 3.2.30SDL_GameControllerDBrow per vendor:product, parsed once at module load; a pad no row knows isunmapped.InputMap: a radial dead zone on both stick pairs,0.2by default (the stockUInputModifierDeadZonevalue), rescaled so full tilt is still 1; a button press or a stick travelling past half tilt claims the read pad and it keeps the slot while idle, so neither an idle wheel in slot 0 nor a stick resting on — or jittering across — the dead zone can steal input, at boot or mid-move; oneTN_GAMEPADline per connect namingid, normalizer and dead zone; an unmapped pad is ignored, never read with raw indices.IInputGamepadgains optionalidandmapping;defineGame({ gamepadDeadZone })is the named override (0publishes raw axes), andctx.input.gamepadDeadZonechanges it while running.@threenative/runtime-nativefillStandardPad(SDL_Gamepad*, GamepadState&)insrc/platform/input.cpp, declared inmystral/platform/input.h, called by the legacy host'sgetGamepadState, by itsgamepadconnectedevent, and by the owned engine player'stnPollGamepads. Both report 4 axes and 16 buttons with the triggers as buttons 6 and 7. The PS4 branch is untouched.@threenative/playtestgamepadstep:{ "kind": "input", "gamepad": { "axes": [1, 0], "buttons": [0] }, "waitTicks": 180 }. The runner installsnavigator.getGamepadsbefore navigation, returns the scripted pad from it, announces it withgamepadconnectedthe way a real pad appears, and keeps the state insessionStorageso a startup reload cannot drop a held pad. A trajectory whose target cannot install a pad refuses the scenario by name instead of reporting green.Search and conventions
InputMapcarries@alias gamepad/controller/joypad/xbox/8bitdo/steam deck, four@situationlines (idle-stick drift, nothing in Firefox, a second pad stealing input, Xbox/generic support), the standard-index/dead-zone/active-pad@constraints and the@override. Thegamepadconvention row renders into every kitAGENTS.md.Proofs run here
pnpm exec vitest run packages/core/__tests__/input.spec.tsvector("move")0; 0.6 → 0.5;gamepadDeadZone = 0→ 0.1;TN_GAMEPADreportsstandard/table/table; an unknown pad fires nothing and logs oncecmake --build build/tn-engine-only --target tn-native-engine-gamepad-standard-test && ctest -R gamepad_standardnumAxes = 6in the shared table fails the test by namepnpm exec vitest run packages/engine-mcp/__tests__/search.spec.tsafterpnpm buildInputMapis the first row for "xbox controller" (2.00), "steam deck" (2.40), "stick drift" (0.61)pnpm sync:agents && pnpm exec vitest run scripts/__tests__/kit-conventions.spec.tsAGENTS.mdandCLAUDE.mdindex the row with its overridepnpm scaffold:pinpnpm build:web, then both new scenarios throughnode packages/playtest/dist/runner/cli.js … --browser-recipe webgpu --headedagainstvite previewpass: true;movementDeltax 28.87 (min 3),state.jumps0 → 1, idle stick 0.1 →maxDistance0 (max 0.5); each run printsTN_GAMEPAD {"deadZone":0.2,…,"mapping":"standard"}pnpm ci:fastcmake --build build/tn-linux-dual --target mystral-runtimelibmystral-runtime.alinked (Android/iOS/PS4 compiles remain for the board)pnpm typecheckpnpm lintover the whole tree still reports pre-existingnoExcessiveCognitiveComplexitywarnings inexamples/abyss-framework,examples/avbd-riggingandexamples/auto-lod; none of those files are touched here, andci:fast's lint lane is green.Review rounds (Opus, high)
Round 1 raised two blockers, fixed in
d1c6d67:RegExp.execreturns a match array and the vendor:product object was rebuilt twice a tick for amapping: ""pad, against Phase 1's own promise.Round 2 blocked the repair itself, fixed in
35d2e77:#padHadInput;input.spec.tslocks both orders — a held pad defending against a drifter, and the player taking the slot back from a drifter that claimed it at boot.mapping: ""pads and round 2's own${index}:${id}reintroduced one on the common path. It is cached per slot:id now.Round 3 blocked the edge rule itself, fixed in
cd4f74a:0.01and claims nothing, while a button press always reclaims.input.spec.tslocks it, and the convention row, its reference page and theInputMaptags now state the claim rule so the words a kit reads match the code..threenative.config.<pid>.<Date.now()>.mjs, so two config loads in the same millisecond in one process collided and the first one'sunlinkdeleted the second's file.viteresolves its config twice throughresolveConfig'sPromise.all, which is why the template lane andci:fast's drift lane flaked intermittently withTN_CONFIG_LOAD_FAILED. A sequence number distinguishes them.Round 4 accepted the gamepad rule and blocked on evidence, fixed in
3d61540:config.spec.tsas if it did.configTempNameis now a named export and a spec asserts two loads sharing a pid and a millisecond get different paths — dropping the sequence fails it deterministically. A second spec loads the config three times underPromise.allwith the clock frozen. Confirmed first-hand that the race does not reproduce under a stubbed clock (the second import still finds the file), so the invariant spec is the lock, andconfig.spec.tsis 119.input.spec.tsis 57).Round 5 accepted the code and blocked on two evidence claims, fixed in
bbe3796:expected false to be true), because an owned idle slot 0 keeps the input.input.spec.tsis 58.import()is served from the module cache, so it passes without the suffix. It is now labelled as loader coverage underPromise.all, andconfigTempNameis named as the lock.Also taken from that review:
gamepadDeadZone: 0really publishes raw axes (no diagonal rescale), an out-of-range or NaN dead zone throws at construction instead of silently reading0.2, theTN_GAMEPADline is keyed per slot:id so two identical pads both report and a window blur does not reprint, and the scripted-pad init script is installed only for a scenario that uses agamepadstep.Not taken, with reasons: the 8BitDo row stays SDL's
:367entry rather than the:516Bluetooth one — both carry the same stick and trigger slots for the 16 mapped buttons, and the hidraw form (:72) that names the triggers as buttons is the one form no browser reports. The d-pad and the SDL~/half-axis forms stay out of scope, as the PRD states.PRD checklist (all ticked in
docs/PRDs/BLOCKED/requires-physical-device/PRD-880-…)tnPollGamepadsandplatform::getGamepadStateidentical for one virtual padInputMap; the convention row renders and passes the index checkDeviations from the PRD's file plan (recorded in its Decisions)
fillStandardPadlives insrc/platform/input.cppwith its declaration inmystral/platform/input.h, not ingamepads.cpp: that file is the only one both hosts already compile, so one definition resolves for the legacy Android host and the V8 player without adding an engine source to the legacy host.gamepads.cppmoved from the V8 adapter executable intotn_engine_playerso one test binary links both entry points.movement.maxDistance.mapping: ""pad. Every SDL row expresses it as a hat, which a browser delivers as two extra axes in a device-specific order, and no kit binds buttons 12 to 15.Follow-ups, not this PRD
gamepadconnected; a real browser fires it, and the playtest step now does too. Recorded in the PRD as a known follow-up.Device read (done on the hardware)
See the final review comment: the owner's 8BitDo Ultimate C 2.4G reports
2dc8:3016, Chromium reports it unmapped, and a measured row now ships for it.Blocked on
2dc8:301band2dc8:3013but noUltimate C 2.4Grow, so that read settles whether the owner's device matches a shipped row or needs its own. The PRD is filed underdocs/PRDs/BLOCKED/requires-physical-device/for exactly this.Implements PRD-880 (filed under
docs/PRDs/BLOCKED/requires-physical-device/; the device read is the only work left).